iT邦幫忙

2024 iThome 鐵人賽

DAY 8
0
JavaScript

Don't make JavaScript Just Surpise系列 第 8

原型與相關關鍵字([[Prototype]],__proto__,.prototype)

  • 分享至 

  • xImage
  •  

上篇的最後我們提到 JS 是種 基於原型(prototype-based)的語言,所謂的原型是什麼呢?

原型指的實際上是 JavaScript 中每個物件都會有的一個內建屬性,在語言規範中被稱為 [[Prototype]],無法用存取一般屬性的方式直接存取。這個屬性實際上會在物件被創建時儲存指向物件鏈接對象的位址,若無特別從某個物件創建而來,則會是內建的 Object

既然原型中指向的是創建範本的物件,而物件都有原型這個屬性,那是不是就可以層層回朔:這就是原型鏈(Prototype Chain)的概念。

原型鏈查找會發生在任一鏈結點上物件被查詢某個他並未持有的方法或屬性時,原型鏈就會被追朔,嘗試對該物件的 [[Prototype]] 訪問是否有該方法或屬性,若仍沒有,繼續往上找 -- 直到原型鏈結束,則此時的訪問就會回傳 undefined

一般來說,大部分物件原型的終點,會追朔到內建的 Object.prototype,接著會發現 Object 的 prototype 是 null,停止鏈的追朔。

let a = {};
a.__proto__;//object{...}
a.__proto__.__proto__;//null

[[Prototype]] 、 proto 、 prototype

討論原型觀念,通常會看到這幾個相關連的關鍵字。看起來十分相似,但其實使用上有許多不同。
首先是第一段提到的,[[Prototype]]是一個內建屬性,不可被訪問。
想要訪問裡面的值,可以使用 __proto__ 來做確認,但實際上 __proto__ 也是 ES 6 才被引入的屬性,可以用 Object 上的方法 getPrototypeOf() 來獲得物件的原型。
參考下面這個例子:

function Human(name) {
  this.name = name;
  this.speak = function() {
    console.log(this.name + ' says Hello');
  };
}
let friend1 = new Human('Ken');
console.log(friend1.__proto__);
console.log(Object.getPrototypeOf(friend1));
console.log(friend1.__proto__ === Object.getPrototypeOf(friend1));//true

我們可以看到最後的比較中,這兩個方式拿出來的物件是等價的。

而 prototype 則是特屬於 function 這個類別的屬性,用來定義類別的實體所繼承的屬性和方法的物件
看下面這個例子,如果能完全搞懂所有的 console 結果,那對這幾個關鍵字的理解就很清楚了。

class Human {
	constructor( name ) {
		this.name = name;
	}
	hello() {
		console.log(`${this.name} says Hello`);
	}
}
class Classmate extends Human {
    constructor(name, studentId) {
        super(name); //沒有放在使用 this 之前會報錯
        this.studentId = studentId;
    }
    showId() {
        console.log(`My student Id is ${this.studentId}`);
    }
}

console.log(typeof Human);//function
console.log(typeof Human.__proto__);//function
console.log(typeof Human.prototype);//object

let friend1 = new Human('Ken');
let friend2 = new Classmate('Ryu');

console.log(friend2.prototype); // undefined
console.log(friend2.__proto__ === Classmate.prototype);
console.log(friend2.__proto__); // Classmate.prototype
console.log(friend2.__proto__.__proto__); // Human.prototype
console.log(friend2.__proto__.__proto__.__proto__); // Object.prototype
console.log(friend2.__proto__.__proto__.__proto__.__proto__); // null

第 6 天所說的,new 的行為是根據對象物件創建一個新的「物件」,可以看到 friend1friend2 型別都是 object,所以並沒有 .prototype 這個屬性,但是 Human 身為函數,是有這個屬性的,指向使用 Human 來建構物件時的物件定義屬性與方法。
__proto__ 是用於物件本身的方法,來追朔他原型鏈上指向的上一個物件。
有一點容易搞混,透過 Classmate 來創建的物件的原型鏈上的上一個物件,就是 Classmate.prototype 本身。

這段的 .prototype 和原型交互出現,容易搞混,務必再次看清楚上面的範例確認搞懂每個關鍵字代表的意義跟使用場合。

建議修改原型的方法

想要明確設置原型鏈,有幾個方法。
如果是創建時,可以使用 Object.create 來明確指向原型。

let a = {foo:'bar'};
let b = Object.create(a);
console.log(b.__proto__ === a);//true

如果今天真的想要手動修改既存物件的原型,可以使用 Object.setPrototypeOf()

let a = {foo:'bar'};
let b = Object.create(a);
console.log(b.__proto__ === a);//true
console.log(b);
let c = {bar:'foo'};//{foo: "bar"}
Object.setPrototypeOf(b,c);
console.log(b.__proto__ === a);//false
console.log(b.__proto__ === c);//true
console.log(b);//{bar: "foo"}

並不會直接修改 __proto__ 的原因主要是因為這並不是那麼意圖明顯的寫法,同時 __proto__ 也並不是一個標準屬性,儘管大多數主流瀏覽器都支援,使用上述的方法會更安全一些。

原型為 null 的情況

正常物件的原型鏈終點都會終於 object,object 的 prototype 再指向 null。
那有辦法建立原型鏈只有自己的物件嗎?有,透過特殊的語法 Object.create(null) 來進行創建。
注意這樣創建的物件因為原型鏈上只有自己,再來就指向 null,這樣一個物件甚至不能用物件的內建方法,通常用於明確指明對象是一個空物件,且避免影響繼承鏈時使用。

const obj1 = {};
const obj2 = Object.create(null);
obj1.a = 1;
obj2.a = 2;
console.log(obj1.hasOwnProperty('a'));// true
console.log(obj2.hasOwnProperty('a'));// TypeError: obj.hasOwnProperty is not a function

//假設註解掉上面會噴錯的那行後執行下面
console.log(obj1.__proto__);//Object
console.log(obj2.__proto__);//undefined
console.log(Object.getPrototypeOf(obj2));//null

這個例子展示了繼承鏈上只有自己的物件創造,另外也可以看到 __proto__ 其實和 Object.getPrototypeOf() 有一種情況下,得到的值會不相同 -- 當該物件原型為 null時。
__proto__ 並不會暴露這件事,而是回傳 undefined,但使用 Object.getPrototypeOf() 可以看到原型是 null

類別上的函式創建寫法差異

最後補充一個類別創建時的寫法差異,先看下面例子。

function Human(name) {
  this.name = name;
  this.hello = function() {
    console.log(this.name + ' says hello.');
  };
}
let friend1 = new Human('Ken');
let friend2 = new Human('Ryu');
console.log(friend1.hello === friend2.hello);//false

Human.prototype.jump = function(){
	console.log(this.name + ' jump up!');
}
console.log(friend1.jump === friend2.jump);//true

class Classmate extends Human{
    constructor(name, studentId){
        super(name);
        this.studentId = studentId;
    }
    showId(){
        console.log(`${this.name} show the id : ${studentId}`);
    }
}
let friend3 = new Classmate('Gouki',9000);
let friend4 = new Classmate('Vega',10000);
console.log(friend3.showId === friend4.showId);//true

不知道有沒有先接觸 ES 6 class 關鍵字的人寫過上面的第一種寫法來用建立物件。
儘管第一種寫法建立的物件,透過 new 建構時的新物件都會具有 hello 函數,但可以透過相等運算符看到並不是指向同一位址。這種寫法實際上會讓 hello 在每次物件被建構時,都額外創建一個 hello 物件。

正確的創建法是通過 Human.prototype 去創建,這樣建構出來的物件都會共享同一個 hello 實體。而 ES 6 的 class 語法糖就幫我們解決了這個問題,第三種寫法中 showId() 的寫法看起來跟第一種 hello() 很像,實際上語法糖裡處理了類似 jump() 的效果,所以這樣的寫法建構出的也是同一個 showId() 實體。


上一篇
JavaScript 的類別(Class)與物件導向(OO)
下一篇
作用域(Scope),let,var 與 const
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言